昨天,我們成功地為天氣 App 打造了數據基礎。
今天,我們將從零開始,搭建顯示天氣資訊的 UI 畫面,並導入 MVVM 架構的大腦——ViewModel,將昨天建立的數據核心與使用者介面完美地連接起來,實現一個完整且職責分明的功能模組。
困惑點:數據已經能從 API 獲取了,但我該如何設計一個能清晰展示多個天氣指標(溫度、天氣狀況、降雨機率等)的列表畫面?
AI 協作
我已經用 Codable 和 async/await 建立了一個網路服務層來獲取天氣數據。目前架構是MVVM。現在我要將這些數據顯示在 UITableView 上會有三個Cell。Cell中有五個Image View跟五個Label,其中一個Image View可以根據回傳回來的代碼改變。請簡單解釋我該怎麼做,並不需要給程式碼。
AI 的回答:
您好,這是一個很棒的架構設計。根據您目前的 MVVM 架構,我將說明如何將網路服務取得的天氣數據,有效地呈現在
UITableView上。
資料流概述
整體流程可以簡化為以下幾個步驟:
- View Model 負責處理數據:
ViewController會呼叫ViewModel中的方法來發起網路請求。ViewModel會使用您已經建立好的async/await網路服務層去取得天氣數據。- 數據傳遞與綁定: 當網路請求完成,
ViewModel會將原始的網路數據(可能是 JSON)轉換成 UI 需要的格式,並將其儲存起來。ViewController透過某種方式(例如:觀察者模式、Combine或SwiftUI的ObservableObject)來監聽ViewModel數據的變化。- 更新 UI: 當
ViewModel的數據更新時,ViewController會收到通知,並呼叫tableView.reloadData()或更精細的批次更新方法來刷新UITableView。- Cell 的數據配置: 在
UITableView的dataSource方法cellForRowAt中,您會從ViewModel提供的數據源(例如一個陣列)取得對應的資料,然後將這些資料傳遞給自定義的UITableViewCell。- Cell 內部處理顯示邏輯:
UITableViewCell會根據傳入的資料,來配置其內部的UILabel和UIImageView。特別是根據您提到的代碼,您可以在 Cell 內部寫一個方法,根據這個代碼值來選擇並設定對應的圖片。
具體實作細節
1. View Model 的職責
- 創建一個天氣數據的模型(
WeatherModel),包含所有您需要的屬性,例如溫度、濕度、天氣描述、天氣代碼等。- 在
ViewModel中,宣告一個陣列來儲存這些WeatherModel物件,例如var weatherData: [WeatherModel] = []。- 提供一個公用方法,例如
fetchWeatherData(),在這個方法中呼叫您的網路服務,並在成功後將返回的數據解析並轉換成[WeatherModel]陣列,然後更新weatherData。2. View Controller 的職責
- 在
ViewController中,初始化您的ViewModel。- 在
viewDidLoad()中,呼叫viewModel.fetchWeatherData()。- 實現
UITableViewDataSource協定。在numberOfRowsInSection中,回傳viewModel.weatherData.count。- 在
cellForRowAt中,透過let weather = viewModel.weatherData[indexPath.row]來取得對應的數據,然後將weather物件傳給您的自定義UITableViewCell。3. 自定義 UITableViewCell 的職責
- 在 Cell 中宣告五個
UIImageView和五個UILabel。- 建立一個配置方法,例如
configure(with weather: WeatherModel)。- 在這個方法中,根據
weather物件中的屬性來設定對應的label.text。- 針對需要根據代碼變化的
UIImageView,您可以使用一個switch或if-else語句,根據weather.weatherCode的值來設定imageView.image = UIImage(named: "圖片名稱")。這能讓您將圖片選擇的邏輯封裝在 Cell 內部,保持ViewController的簡潔。這種架構可以有效分離關注點,讓您的數據處理(在
ViewModel中)、UI 顯示(在ViewController中)、以及單個 Cell 的顯示邏輯(在UITableViewCell中)各自獨立,易於維護和擴展。
WeatherTableViewCell 的 .swift 和 .xib 檔案,並完成 UI 佈局。AreaViewController,並在其中放置 UITableView 作為列表的容器。AreaViewModel.swift,負責處理所有業務邏輯與狀態管理。AreaViewController 中,實現 ViewModel 的綁定,將數據流與 UI 徹底連接。WeatherTableViewCell)我們需要一個自定義的 Cell 來呈現每個時間區段的天氣資訊。
Views 資料夾下建立一個 Cells 子資料夾,並在其中建立一個新的 Cocoa Touch Class,命名為 WeatherTableViewCell,記得勾選 Also create XIB file。WeatherTableViewCell.xib,從元件庫中拖曳多個 UIImageView 和 UILabel,分別用來顯示時間、舒適度、天氣狀況、最低溫、最高溫和天氣圖示,其中天氣圖示先不指定,我們要用程式碼來判斷回傳的天氣代碼來改變天氣圖示的圖。
WeatherTableViewCell.swift 連結成 IBOutlet。檔案:Views/Cells/WeatherTableViewCell.swift
import UIKit
class WeatherTableViewCell: UITableViewCell {
@IBOutlet weak var lbTime: UILabel!
@IBOutlet weak var lbCI: UILabel!
@IBOutlet weak var lbWx: UILabel!
@IBOutlet weak var lbMinT: UILabel!
@IBOutlet weak var lbMaxT: UILabel!
@IBOutlet weak var imgvWx: UIImageView!
override func awakeFromNib() {
super.awakeFromNib()
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
}
}
AreaViewController)這個 ViewController 將作為顯示天氣列表的容器。
Views 資料夾下建立一個 AreaView 子資料夾,並在其中建立一個 AreaViewController 的 .swift 和 .xib 檔案。AreaViewController.xib,拖曳一個 UITableView 到畫布上,並設定其 Auto Layout 約束,使其四邊都與安全區域對齊。UITableView 連結到 AreaViewController.swift,命名為 tbvWeather。AreaViewModel)檔案:ViewModels/AreaViewModel.swift
import Foundation
import UIKit
// MARK: - View State Enum
enum ViewState {
case loading
case success
case failure(String)
}
// MARK: - Weather Cell ViewModel
struct WeatherCellViewModel {
let time: String
let comfort: String
let weatherCondition: String
let minTemperature: String
let maxTemperature: String
let weatherIconName: String
}
// MARK: - Area ViewModel
class AreaViewModel {
// MARK: - Properties
private let weatherService = WeatherService.shared
var viewState: ((ViewState) -> Void)?
private(set) var cellViewModels: [WeatherCellViewModel] = []
// 天氣代碼與 SF Symbol 圖示名稱的對應字典
private let weatherCodeMap: [String: String] = [
// MARK: - 晴天類 (Clear)
"1": "sun.max.circle", // 晴天
// MARK: - 多雲類 (Cloudy)
"2": "cloud.sun.circle", // 晴時多雲
"3": "cloud.sun.circle", // 多雲時晴
"4": "cloud.circle", // 多雲
"5": "cloud.circle", // 多雲時陰
"6": "cloud.circle", // 陰時多雲
"7": "cloud.circle", // 陰天
// MARK: - 下雨類 (Rain)
"8": "cloud.drizzle.circle", // 多雲短暫雨/陣雨 (小雨)
"9": "cloud.drizzle.circle", // 多雲時陰短暫雨/陣雨
"10": "cloud.drizzle.circle", // 陰時多雲短暫雨/陣雨
"11": "cloud.rain.circle", // 雨天/陰短暫雨 (雨)
"12": "cloud.rain.circle", // 多雲時陰有雨/陣雨
"13": "cloud.rain.circle", // 陰時多雲有雨/陣雨
"14": "cloud.heavyrain.circle", // 陰有雨/陣雨 (大雨)
"19": "cloud.rain.circle", // 晴午後多雲局部雨
"20": "cloud.rain.circle", // 多雲午後局部雨
"29": "cloud.rain.circle", // 多雲局部雨
"30": "cloud.rain.circle", // 多雲時陰局部雨/陰局部雨
"38": "cloud.rain.circle", // 短暫陣雨/雨有霧
"39": "cloud.heavyrain.circle", // 有雨/陣雨有霧 (大雨)
// MARK: - 雷雨類 (Thunderstorm)
"15": "cloud.bolt.rain.circle", // 多雲短暫陣雨或雷雨
"16": "cloud.bolt.rain.circle", // 多雲時陰短暫陣雨或雷雨
"17": "cloud.bolt.rain.circle", // 陰時多雲有雷陣雨
"18": "cloud.bolt.rain.circle", // 陰有陣雨或雷雨
"21": "cloud.bolt.rain.circle", // 晴午後多雲雷陣雨
"22": "cloud.bolt.rain.circle", // 多雲午後局部雷陣雨
"33": "cloud.bolt.rain.circle", // 多雲局部陣雨或雷雨
"34": "cloud.bolt.rain.circle", // 多雲時陰局部陣雨或雷雨
"35": "cloud.bolt.rain.circle", // 多雲有陣雨或雷雨有霧
"36": "cloud.bolt.rain.circle", // 多雲時陰有陣雨或雷雨有霧
"41": "cloud.bolt.rain.circle", // 短暫陣雨或雷雨有霧
// MARK: - 下雪類 (Snow)
"23": "cloud.snow.circle", // 雨或雪
"42": "wind.snow", // 暴風雪
// MARK: - 起霧類 (Fog)
"24": "cloud.fog.circle", // 晴有霧
"25": "cloud.fog.circle", // 晴時多雲有霧
"26": "cloud.fog.circle", // 多雲時晴有霧
"27": "cloud.fog.circle", // 多雲有霧
"28": "cloud.fog.circle", // 陰有霧
"31": "cloud.fog.circle", // 多雲有霧有局部雨
"32": "cloud.fog.circle", // 多雲時陰有霧有局部雨
"37": "cloud.fog.circle" // 局部雨或雪有霧
]
// MARK: - Function
func fetchWeather(for area: String) {
viewState?(.loading)
Task {
do {
let weatherData = try await weatherService.fetchWeatherData(for: area)
await MainActor.run {
self.processWeatherData(weatherData, for: area)
self.viewState?(.success)
}
} catch {
await MainActor.run {
let errorMessage = self.convertErrorToUserMessage(error)
self.viewState?(.failure(errorMessage))
}
}
}
}
private func processWeatherData(_ weatherData: WeatherData, for area: String) {
guard let locationData = weatherData.records.location.first(where: { $0.locationName == area }) else {
cellViewModels = []
return
}
guard let wxElement = locationData.weatherElement.first(where: { $0.elementName == "Wx" }),
let popElement = locationData.weatherElement.first(where: { $0.elementName == "PoP" }),
let maxTElement = locationData.weatherElement.first(where: { $0.elementName == "MaxT" }),
let minTElement = locationData.weatherElement.first(where: { $0.elementName == "MinT" }),
let ciElement = locationData.weatherElement.first(where: { $0.elementName == "CI" }) else {
cellViewModels = []
return
}
let maxCount = min(wxElement.time.count, popElement.time.count, maxTElement.time.count, minTElement.time.count, ciElement.time.count)
cellViewModels = (0..<maxCount).map { index in
let wxTimeData = wxElement.time[index]
let popTimeData = popElement.time[index]
let maxTTimeData = maxTElement.time[index]
let minTTimeData = minTElement.time[index]
let ciTimeData = ciElement.time[index]
let weatherName = wxTimeData.parameter.parameterName
let weatherCode = wxTimeData.parameter.parameterValue ?? ""
let popValue = popTimeData.parameter.parameterName
let maxTemp = maxTTimeData.parameter.parameterName
let minTemp = minTTimeData.parameter.parameterName
let comfort = ciTimeData.parameter.parameterName
let startTime = formatTime(wxTimeData.startTime)
let endTime = formatTime(wxTimeData.endTime)
let timeString = "\(startTime) - \(endTime)"
let weatherCondition = "\(weatherName) (降雨機率 \(popValue)%)"
let iconName = getWeatherIconName(fromCode: weatherCode)
return WeatherCellViewModel(
time: timeString,
comfort: comfort,
weatherCondition: weatherCondition,
minTemperature: "\(minTemp)°C",
maxTemperature: "\(maxTemp)°C",
weatherIconName: iconName
)
}
}
private func getWeatherIconName(fromCode code: String) -> String {
return weatherCodeMap[code] ?? "questionmark.circle"
}
private func formatTime(_ timeString: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
if let date = formatter.date(from: timeString) {
formatter.dateFormat = "MM/dd HH:mm"
return formatter.string(from: date)
}
return timeString
}
private func convertErrorToUserMessage(_ error: Error) -> String {
if let networkError = error as? NetworkError {
return networkError.localizedDescription
}
return "發生未知錯誤:\(error.localizedDescription)"
}
}
最後,我們回到 AreaViewController,將它與 ViewModel 連接起來。
檔案:Views/AreaView/AreaViewController.swift
import UIKit
class AreaViewController: UIViewController {
// MARK: - IBOutlets
@IBOutlet weak var tbvWeather: UITableView!
// MARK: - Properties
var selectedArea: String?
private let viewModel = AreaViewModel()
private let activityIndicator = UIActivityIndicatorView(style: .large)
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
bindViewModel()
if let area = selectedArea {
viewModel.fetchWeather(for: area)
}
}
// MARK: - UI Setup
func setupUI() {
view.backgroundColor = .systemBackground
tbvWeather.dataSource = self
tbvWeather.delegate = self
let nib = UINib(nibName: "WeatherTableViewCell", bundle: nil)
tbvWeather.register(nib, forCellReuseIdentifier: "WeatherCell")
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(dismissVC))
// 設定 Activity Indicator
activityIndicator.center = view.center
activityIndicator.color = .systemBlue
view.addSubview(activityIndicator)
}
// MARK: - ViewModel Binding
private func bindViewModel() {
viewModel.viewState = { [weak self] state in
DispatchQueue.main.async {
switch state {
case .loading:
self?.activityIndicator.startAnimating()
self?.tbvWeather.isHidden = true
self?.title = "載入中..."
case .success:
self?.activityIndicator.stopAnimating()
self?.tbvWeather.isHidden = false
self?.title = self?.selectedArea
self?.tbvWeather.reloadData()
case .failure(let errorMessage):
self?.activityIndicator.stopAnimating()
self?.tbvWeather.isHidden = true
self?.title = "載入失敗"
self?.showErrorAlert(message: errorMessage)
}
}
}
}
// MARK: - Actions
@objc func dismissVC() {
self.dismiss(animated: true, completion: nil)
}
// MARK: - Helper Methods
private func showErrorAlert(message: String) {
let alert = UIAlertController(title: "錯誤", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "確定", style: .default))
present(alert, animated: true)
}
}
// MARK: - UITableView DataSource & Delegate
extension AreaViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.cellViewModels.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "WeatherCell", for: indexPath) as! WeatherTableViewCell
let cellViewModel = viewModel.cellViewModels[indexPath.row]
// 使用 ViewModel 中已格式化的資料
cell.lbTime.text = cellViewModel.time
cell.lbCI.text = cellViewModel.comfort
cell.lbWx.text = cellViewModel.weatherCondition
cell.lbMinT.text = cellViewModel.minTemperature
cell.lbMaxT.text = cellViewModel.maxTemperature
cell.imgvWx.image = UIImage(systemName: cellViewModel.weatherIconName)
cell.imgvWx.tintColor = .systemBlue
return cell
}
}
今天,我們打造了顯示天氣資訊的 UI 介面,ViewController 現在只負責單純的顯示工作,所有複雜的業務邏輯和狀態管理,都交給了 ViewModel 處理。
App 的核心功能模組已經完成。明天,我們將打造一個互動式的地圖介面,讓使用者可以直觀地在地圖上選擇城市,並將這個主畫面與我們今天完成的天氣詳情頁完美地串連起來!